昨天我們完成了第一版 RAG,但目前只能靠肉眼判斷答案好不好。今天要把「好不好」變成數字:準備一份有標準答案的測試集,讓 RAG 跑一遍,再用 RAGAS 的四個核心指標評分,存成第一份 baseline。之後每次調整系統,都拿新分數跟這份 baseline 比較。
NaN 代表什麼測試集是評估的地基。我們依 DAY23 準備的 5 份文件,每份出 4 題,放在 eval/testset.json。每一題的格式如下:
[
...
{
"id": 10,
"source": "http_rest_api.md",
"type": "比較",
"question": "401 和 403 狀態碼有什麼差別?",
"reference": "401 Unauthorized 代表尚未通過身分驗證;403 Forbidden 代表已驗證身分但沒有權限存取。"
},
...
]
| 欄位 | 用途 |
|---|---|
question |
丟給 RAG 的問題 |
reference |
標準答案,Context Recall 與 Context Precision 需要它 |
source |
答案應該出現的文件,DAY29 做錯誤分析時,用來判斷檢索有沒有撈對文件 |
type |
題型(事實、比較、細節),之後可以看哪種題型表現較差 |
新建 evaluate_rag.py,先寫前半段:載入測試集、讓 RAG 逐題回答,並記錄每題的回應時間。
import json
import time
from pathlib import Path
from langchain_ollama import ChatOllama, OllamaEmbeddings
from ragas import EvaluationDataset, evaluate
from ragas.dataset_schema import SingleTurnSample
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import answer_relevancy, context_precision, context_recall, faithfulness
from ragas.run_config import RunConfig
from build_rag import EMBED_MODEL, LLM_MODEL, OLLAMA_URL, build_rag_chain, get_vectorstore
EVAL_DIR = Path("eval")
TESTSET_PATH = EVAL_DIR / "testset.json"
OUTPUTS_PATH = EVAL_DIR / "rag_outputs.json"
BASELINE_PATH = EVAL_DIR / "baseline.csv"
METRIC_NAMES = ["faithfulness", "answer_relevancy", "context_precision", "context_recall"]
def load_testset(limit=None):
testset = json.loads(TESTSET_PATH.read_text(encoding="utf-8"))
return testset[:limit] if limit else testset
def run_rag(chain, testset):
rows = []
for i, item in enumerate(testset, 1):
start = time.perf_counter()
result = chain.invoke(item["question"])
latency = time.perf_counter() - start
rows.append({
"id": item["id"],
"source": item["source"],
"type": item["type"],
"question": item["question"],
"reference": item["reference"],
"answer": result["answer"],
"contexts": [doc.page_content for doc in result["context"]],
"latency": round(latency, 2),
})
print(f"[{i}/{len(testset)}] {latency:5.1f}s | {item['question']}")
OUTPUTS_PATH.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
return rows
RAGAS 的指標本身是由 LLM 擔任「評審」:例如 Faithfulness 會請模型把答案拆成一條條說法,再逐條檢查是否有依據。所以我們要告訴 RAGAS 用哪個模型當評審:
def get_judge():
llm = LangchainLLMWrapper(
ChatOllama(model=LLM_MODEL, base_url=OLLAMA_URL, temperature=0, reasoning=False)
)
embeddings = LangchainEmbeddingsWrapper(
OllamaEmbeddings(model=EMBED_MODEL, base_url=OLLAMA_URL)
)
return llm, embeddings
def score(rows):
dataset = EvaluationDataset(samples=[
SingleTurnSample(
user_input=r["question"],
response=r["answer"],
retrieved_contexts=r["contexts"],
reference=r["reference"],
)
for r in rows
])
llm, embeddings = get_judge()
result = evaluate(
dataset=dataset,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
llm=llm,
embeddings=embeddings,
run_config=RunConfig(timeout=600, max_workers=2),
raise_exceptions=False,
)
df = result.to_pandas()
df["id"] = [r["id"] for r in rows]
df["source"] = [r["source"] for r in rows]
df["latency"] = [r["latency"] for r in rows]
return df
SingleTurnSample 的四個欄位,正是 RAGAS 需要的四樣東西:問題、答案、檢索到的內容、標準答案。
RunConfig 這一行在本地模型上很重要:
| 參數 | 為什麼這樣設 |
|---|---|
max_workers=2 |
RAGAS 預設會同時發出大量請求,雲端 API 沒問題,但本機的 Ollama 同時處理太多會變得很慢甚至逾時。限制成 2 比較穩 |
timeout=600 |
本地 8B 模型單次呼叫可能要好幾秒,預設的逾時時間容易不夠,結果變成無法計算 |
raise_exceptions=False |
某一題評分失敗時不要讓整個程式中斷,該題記為 NaN,其他題繼續 |
再加上報表與主程式:
def report(df):
print("\n===== Baseline =====")
print(df[METRIC_NAMES].mean().round(3).to_string())
print("\n無法計算(NaN)的題數:")
print(df[METRIC_NAMES].isna().sum().to_string())
print(f"\n平均回應時間:{df['latency'].mean():.1f} 秒(最慢 {df['latency'].max():.1f} 秒)")
LIMIT = 3 # 先用少量題目確認流程,確認無誤後改成 None(跑全部 20 題)
RERUN_RAG = True # 只想重新評分、不想重新生成答案時,改成 False
if __name__ == "__main__":
EVAL_DIR.mkdir(exist_ok=True)
if RERUN_RAG or not OUTPUTS_PATH.exists():
chain = build_rag_chain(get_vectorstore())
rows = run_rag(chain, load_testset(LIMIT))
else:
rows = json.loads(OUTPUTS_PATH.read_text(encoding="utf-8"))
df = score(rows)
df.to_csv(BASELINE_PATH, index=False, encoding="utf-8-sig")
report(df)
print(f"\n已儲存:{BASELINE_PATH}")
CSV 使用 utf-8-sig 編碼,直接用 Excel 開啟中文才不會變亂碼。
先跑 3 題,再跑 20 題。 本地模型評分很耗時:每一題每個指標都會呼叫好幾次 LLM(Context Precision 甚至每個檢索到的 chunk 都要判斷一次),20 題大約是兩百次以上的模型呼叫。先用 LIMIT = 3 確認整條流程沒問題,再改成 None 跑完整測試集,執行期間可能要等十幾到三十分鐘以上,視你的硬體而定。
執行:
uv run python evaluate_rag.py
跑完會得到每個指標的平均分數(0 到 1,越高越好)、NaN 的題數與平均回應時間,並產生 eval/baseline.csv。讀結果時,請依序做三件事:
1. 先看 NaN 的數量。 NaN 代表評審模型這題沒有產生可解析的結果,常見訊息是 failed to parse output。平均分數會自動略過 NaN,所以如果某個指標有一半的題目是 NaN,那個平均值幾乎沒有參考價值。遇到這種情況,優先調整 RunConfig 或換評審模型,而不是急著解讀分數。
2. 看哪個指標最低。 不同指標偏低,指向不同的問題:
| 偏低的指標 | 優先懷疑 | DAY29 可以調整 |
|---|---|---|
| Context Recall | 沒撈到該撈的內容 | k、chunk_size |
| Context Precision | 撈到太多不相關內容 | k、chunk_size |
| Faithfulness | 模型編造或超出資料 | prompt 的限制條件 |
| Answer Relevancy | 答非所問 | prompt、模型 |
3. 打開 CSV,看分數最低的幾題。 平均數會掩蓋問題,真正的線索在個別題目裡。
請把 eval/baseline.csv 好好保留,之後的改動都跟它比。同時記下當時的設定,例如 chunk_size=500、chunk_overlap=50、k=4、模型是 qwen3:8b。沒有記錄設定的分數,之後就無法解釋為什麼變好或變差。
另外,本地小模型評審有隨機性,同一份資料重跑兩次,分數可能有小幅差異。看到 0.02 之類的微小變化,不要急著下結論,差距要夠大、或在多題上一致出現,才有意義。
今天建立了評估流程,明天我們要補上 RAGAS 內建指標涵蓋不到的面向:自訂評估指標,例如檢查答案是否使用繁體中文、遇到知識庫以外的問題是否正確拒答。